Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit ad3a0b9df6f232ce600c78796f47f058a7955b38


Parents : beff037
Author : Mark Qvist <mark@unsigned.io>
Date : 2024-03-27T01:09:45+01:00

Added remote view plugin example

Changes

1 files changed, 324 insertions(+), 0 deletions(-)


Diff

diff --git a/docs/example_plugins/view.py b/docs/example_plugins/view.py
new file mode 100644
index 00000000..b339f974
--- /dev/null
+++ b/docs/example_plugins/view.py
@@ -0,0 +1,324 @@
+# This plugin lets you remotely query and view a
+# number of different image sources in Sideband.
+#
+# This plugin requires the "pillow" pip package.
+#
+# For HTTP and local file sources, no extras are
+# required, but for fetching images from connected
+# video sources, you need "opencv-python" from pip.
+
+import io
+import os
+import RNS
+import time
+import queue
+import requests
+import threading
+import importlib
+from PIL import Image as PilImage
+
+if importlib.util.find_spec("cv2") != None:
+ import cv2
+
+# Add view sources to the plugin
+def register_view_sources():
+ ViewCommandPlugin.add_source("xkcd", HttpSource("https://imgs.xkcd.com/comics/tsp_vs_tbsp.png"))
+ ViewCommandPlugin.add_source("camera", CameraSource(camera_index=0))
+ ViewCommandPlugin.add_source("rocks", FileSource("~/Downloads/rocks.jpg"))
+ ViewCommandPlugin.add_source("osaka", StreamSource("http://honjin1.miemasu.net/nphMotionJpeg?Resolution=640x480&Quality=Standard"))
+ ViewCommandPlugin.add_source("factory", StreamSource("http://takemotopiano.aa1.netvolante.jp:8190/nphMotionJpeg?Resolution=640x480&Quality=Standard&Framerate=1"))
+
+quality_presets = {
+ "lora": {"max": 160, "quality": 18},
+ "low": {"max": 256, "quality": 25},
+ "default": {"max": 320, "quality": 33},
+ "medium": {"max": 480, "quality": 50},
+ "high": {"max": 960, "quality": 65},
+ "hd": {"max": 1920, "quality": 75},
+ "4k": {"max": 3840, "quality": 65},
+}
+
+if not "default" in quality_presets:
+ raise ValueError("No default quality preset defined, please define one and reload the plugin")
+
+class ViewSource():
+ DEFAULT_STALE_TIME = 3.14159
+
+ def __init__(self):
+ self.source_data = None
+ self.last_update = 0
+ self.stale_time = ViewSource.DEFAULT_STALE_TIME
+
+ def is_stale(self):
+ return time.time() > self.last_update + self.stale_time
+
+ def update(self):
+ raise NotImplementedError()
+
+ def scaled_image(self, max_dimension, quality):
+ with PilImage.open(io.BytesIO(self.source_data)) as im:
+ im.thumbnail((max_dimension, max_dimension))
+ buf = io.BytesIO()
+ im.save(buf, format="webp", quality=quality)
+ return buf.getvalue()
+
+ def get_image_field(self, preset="default"):
+ if not preset in quality_presets:
+ preset = "default"
+
+ try:
+ if self.is_stale():
+ self.update()
+
+ if self.source_data != None:
+ max_dimension = quality_presets[preset]["max"]
+ quality = quality_presets[preset]["quality"]
+ return ["webp", self.scaled_image(max_dimension, quality)]
+
+ except Exception as e:
+ RNS.log(f"Could not create image field for {self}. The contained exception was: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+
+ return None
+
+
+class HttpSource(ViewSource):
+ def __init__(self, url):
+ self.url = url
+ super().__init__()
+
+ def update(self):
+ image_request = requests.get(self.url, stream=True)
+ if image_request.status_code == 200:
+ self.source_data = image_request.content
+ self.last_update = time.time()
+ else:
+ self.source_data = None
+
+class CameraSource(ViewSource):
+ def __init__(self, camera_index=0, camera_width=1280, camera_height=720):
+ self.camera_index = camera_index
+ self.camera_width = camera_width
+ self.camera_height = camera_height
+ self.camera_ready = False
+ self.frame_queue = queue.Queue()
+ super().__init__()
+
+ self.start_reading()
+
+ def start_reading(self):
+ self.camera = cv2.VideoCapture(self.camera_index)
+ self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.camera_width)
+ self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.camera_height)
+ threading.Thread(target=self.read_frames, daemon=True).start()
+
+ def read_frames(self):
+ try:
+ while True:
+ ret, frame = self.camera.read()
+ self.camera_ready = True
+ if not ret:
+ self.camera_ready = False
+ break
+
+ if not self.frame_queue.empty():
+ try:
+ self.frame_queue.get_nowait()
+ except queue.Empty:
+ pass
+
+ self.frame_queue.put(frame)
+
+ except Exception as e:
+ RNS.log("An error occurred while reading frames from the camera: "+str(e), RNS.LOG_ERROR)
+
+ self.release_camera()
+
+ def update(self):
+ if not self.camera:
+ self.start_reading()
+ while not self.camera_ready:
+ time.sleep(0.2)
+
+ retval, frame = self.camera.read()
+
+ if not retval:
+ self.source_data = None
+ else:
+ retval, buffer = cv2.imencode(".png", frame)
+ self.source_data = io.BytesIO(buffer).getvalue()
+ self.last_update = time.time()
+
+ def release_camera(self):
+ try:
+ self.camera.release()
+ except:
+ pass
+
+ self.camera = None
+ self.camera_ready = False
+
+class StreamSource(ViewSource):
+ DEFAULT_IDLE_TIMEOUT = 5
+
+ def __init__(self, url=None):
+ self.url = url
+ self.stream_ready = False
+ self.frame_queue = queue.Queue()
+ self.stream = None
+ self.started = 0
+ self.idle_timeout = StreamSource.DEFAULT_IDLE_TIMEOUT
+ super().__init__()
+
+ self.start_reading()
+
+ def start_reading(self):
+ self.stream = cv2.VideoCapture(self.url)
+ self.started = time.time()
+ threading.Thread(target=self.read_frames, daemon=True).start()
+
+ def read_frames(self):
+ try:
+ while max(self.last_update, self.started)+self.idle_timeout > time.time():
+ ret, frame = self.stream.read()
+ self.stream_ready = True
+ if not ret:
+ self.stream_ready = False
+ break
+
+ if not self.frame_queue.empty():
+ if self.frame_queue.qsize() > 1:
+ try:
+ self.frame_queue.get_nowait()
+ except queue.Empty:
+ pass
+
+ self.frame_queue.put(frame)
+
+ RNS.log(str(self)+" idled", RNS.LOG_DEBUG)
+
+ except Exception as e:
+ RNS.log("An error occurred while reading frames from the stream: "+str(e), RNS.LOG_ERROR)
+
+ self.release_stream()
+
+ def update(self):
+ if not self.stream:
+ self.start_reading()
+ while not self.stream_ready:
+ time.sleep(0.2)
+
+ frame = self.frame_queue.get()
+ retval, buffer = cv2.imencode(".png", frame)
+ self.source_data = io.BytesIO(buffer).getvalue()
+ self.last_update = time.time()
+
+ def release_stream(self):
+ try:
+ self.stream.release()
+ except:
+ pass
+
+ self.stream = None
+ self.stream_ready = False
+
+class FileSource(ViewSource):
+ def __init__(self, path):
+ self.path = os.path.expanduser(path)
+ super().__init__()
+
+ def update(self):
+ try:
+ with open(self.path, "rb") as image_file:
+ self.source_data = image_file.read()
+
+ except Exception as e:
+ RNS.log("Could not read image at \"{self.path}\": "+str(e), RNS.LOG_ERROR)
+ self.source_data = None
+
+class ViewCommandPlugin(SidebandCommandPlugin):
+ command_name = "view"
+ sources = {}
+
+ stamptimefmt = "%Y-%m-%d %H:%M:%S"
+
+ def start(self):
+ RNS.log("View command plugin starting...")
+ super().start()
+
+ def stop(self):
+ super().stop()
+
+ @staticmethod
+ def add_source(name, source):
+ ViewCommandPlugin.sources[name] = source
+
+ def message_response(self, message, destination):
+ self.get_sideband().send_message(
+ message,
+ destination,
+ False, # Don't use propagation by default, try direct first
+ skip_fields = True, # Don't include any additional fields automatically
+ no_display = True, # Don't display this message in the message stream
+ )
+
+ def image_response(self, message, image_field, destination):
+ self.get_sideband().send_message(
+ message,
+ destination,
+ False, # Don't use propagation by default, try direct first
+ skip_fields = True, # Don't include any additional fields automatically
+ no_display = True, # Don't display this message in the message stream
+ image = image_field, # Add the scaled and compressed image
+ )
+
+ def timestamp_str(self, time_s):
+ timestamp = time.localtime(time_s)
+ return time.strftime(self.stamptimefmt, timestamp)
+
+ def handle_command(self, arguments, lxm):
+ requestor = lxm.source_hash
+
+ if len(arguments) == 0:
+ self.message_response("No view source was specified", requestor)
+ return
+
+ if arguments[0] == "--list" or arguments[0] == "-l":
+ if len(self.sources) == 0:
+ response = "No sources available on this system"
+ else:
+ response = "Available Sources:\n"
+ for source in self.sources:
+ response += "\n - "+str(source)
+
+ self.message_response(response, requestor)
+ return
+
+ try:
+ source = arguments[0]
+ if len(arguments) > 1:
+ quality_preset = arguments[1]
+ else:
+ quality_preset = "default"
+
+ if not source in self.sources:
+ self.message_response("The specified view source does not exist on this system", requestor)
+
+ else:
+ image_field = self.sources[source].get_image_field(quality_preset)
+ image_timestamp = self.timestamp_str(self.sources[source].last_update)
+ message = f"Source [b]{source}[/b] at [b]{image_timestamp}[/b]"
+
+ if image_field != None:
+ self.image_response(message, image_field, requestor)
+ else:
+ self.message_response("The image source could not be retrieved or prepared", requestor)
+
+ except Exception as e:
+ self.message_response(f"An error occurred:\n\n{e}", requestor)
+
+register_view_sources()
+
+# Finally, tell Sideband what class in this
+# file is the actual plugin class.
+plugin_class = ViewCommandPlugin
\ No newline at end of file


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────